import {parentOverlayOf, findOverLaysRootedInOverlay} from "./overlay-utils.js";
import {getDeepElementFromPoint} from "../tools/shared/get-deep-element-from-point"
import {OverlayCloseEvent} from './overlay-events.js';
import {ActiveOverlay} from './ActiveOverlay.js';
import {OverlayTimer} from "./OverlayTimer.js";


/**
 * @function nextFrame
 * @return {Promise<void>}
 */
function nextFrame() {
    return new Promise((res) => requestAnimationFrame(() => res()));
}

/**
 * @class OverlayStack
 */
export class OverlayStack {
    /**
     * @public
     * @type {ActiveOverlay[]}
     */
    overlays = [];

    /**
     * @private
     * @type {boolean}
     */
    preventMouseRootClose = false;
    /**
     * document.body
     * @private
     * @type {HTMLElement}
     */
    root = document.body;

    /** @type {boolean} */
    handlingResize = false;

    overlayTimer = new OverlayTimer();
    /**
     * @private
     * @type {boolean}
     */
    trappingInited = false;
    /**
     * @private
     * @type {boolean}
     */
    canTabTrap = true;
    /**
     * @private
     * @type {HTMLElement}
     */
    tabTrapper;
    /**
     * @private
     * @type {HTMLElement}
     */
    overlayHolder;
    /**
     * @type {boolean}
     * @private
     */
    _eventsAreBound = false;

    constructor() {
        this.initTabTrapping();
    }

    get document() {
        return this.root.ownerDocument || document;
    }

    /**
     * @public
     * @function initTabTrapping
     */
    initTabTrapping() {
        if (document.readyState === "loading") {
            document.addEventListener(
                'readystatechange',
                () => this.initTabTrapping(),
                {once: true}
            );
            return;
        }
        if (this.trappingInited) return;
        if (this.document.body.shadowRoot) {
            this.canTabTrap = false;
            return;
        }
        const root = this.document.body.attachShadow({mode: "open"});
        root.innerHTML = `
            <style>
            :host{
                position: relative;
            }
            #actual{
                position: relative;
                height: calc(100% -var(--swc-body-margins-block, 0));
                z-index: 0;
                min-height: calc(100vh- var(--swc-body-margins-block, 0));
            }
            #holder {
                position: absolute;
                top: 0;
                left: 0;
                display: none;
                align-items: center;
                justify-content: center;
                flex-flow: column;
                width: 100%;
                height: 100%;
            }
            [name="open"]::slotted(*) {
                pointer-events: all;
            }
            #actual[aria-hidden] + #holder{
                display: flex;
            }
            </style>
            <div id="actual"><slot></slot></div>
            <div id="holder"><slot name="open"></slot></div>
        `;
        this.tabTrapper = root.querySelector("#actual");
        this.overlayHolder = root.querySelector("#holder");
        this.tabTrapper.attachShadow({mode: "open"});
        if (this.tabTrapper.shadowRoot) {
            this.tabTrapper.shadowRoot.innerHTML = "<slot></slot>";
        }
        this.overlayHolder.addEventListener(
            "contextmenu",
            this.forwardContextmenuEvent,
            true
        );
        requestAnimationFrame(() => {
            this.applyBodyMargins();
            const observer = new ResizeObserver(() => {
                this.applyBodyMargins();
            });
            observer.observe(document.body);
        });
    }

    /**
     *
     * @type {boolean}
     * @private
     */
    _bodyMarginsApplied = false;

    /**
     * @private
     */
    applyBodyMargins() {
        const {marginLeft, marginRight, marginTop, marginBottom} =
            getComputedStyle(document.body);
        const allZero =
            parseFloat(marginLeft) === 0 &&
            parseFloat(marginRight) === 0 &&
            parseFloat(marginTop) === 0 &&
            parseFloat(marginBottom) === 0;
        if (allZero && !this._bodyMarginsApplied) {
            return;
        }
        this.tabTrapper.style.setProperty(
            '--swc-body-margins-inline',
            `calc(${marginLeft} + ${marginRight})`
        );
        this.tabTrapper.style.setProperty(
            '--swc-body-margins-block',
            `calc(${marginTop} + ${marginBottom})`
        );
        this._bodyMarginsApplied = !allZero;
    }

    forwardContextmenuEvent = async (event) => {
        const topOverlay = this.overlays[this.overlays.length - 1];
        if (
            !this.trappingInited ||
            topOverlay.interaction !== 'modal' ||
            event.target !== this.overlayHolder
        ) {
            return;
        }
        event.stopPropagation();
        event.preventDefault();
        await this.closeTopOverlay();
        getDeepElementFromPoint(event.clientX, event.clientY)?.dispatchEvent(
            new MouseEvent('contextmenu', event)
        );
        ``
    }

    /**
     * @private
     */
    startTabTrapping() {
        this.initTabTrapping();
        /* c8 ignore next 3 */
        if (!this.canTabTrap) {
            return;
        }
        this.tabTrapper.tabIndex = -1;
        this.tabTrapper.setAttribute('aria-hidden', 'true');
    }

    /**
     * @private
     */
    stopTabTrapping() {
        /* c8 ignore next 3 */
        if (!this.canTabTrap || !this.trappingInited) {
            return;
        }
        this.tabTrapper.removeAttribute('tabindex');
    }

    /**
     * @private
     * @return {ActiveOverlay | undefined }
     */
    get topOverlay() {
        return this.overlays.slice(-1)[0];
    }

    /**
     * @private
     * @param {HTMLElement} overlayContent
     * @return {ActiveOverlay|undefined}
     */
    findOverlayForContent(overlayContent) {
        for (const item of this.overlays) {
            if (overlayContent === item.overlayContent) {
                return item;
            }
        }
        return undefined;
    }

    /**
     * @private
     */
    addEventListeners() {
        if (this._eventsAreBound) return;
        this._eventsAreBound = true;
        this.document.addEventListener('click', this.handleMouseCapture, true);
        this.document.addEventListener('click', this.handleMouse);
        this.document.addEventListener('keydown', this.handleKeydown);
        this.document.addEventListener(
            'sp-overlay-close',
            this.handleOverlayClose
        );
        window.addEventListener('resize', this.handleResize);
    }

    /**
     * @public
     * @param {OverlayCloseEvent} event
     */
    handleOverlayClose(event) {
        const {root} = event;
        if (!root) return;
        this.closeOverlaysForRoot(root);
    }

    /**
     * @private
     * @param {HTMLElement} trigger
     * @return {boolean}
     */
    isClickOverlayActiveForTrigger(trigger) {
        return this.overlays.some(
            (item) => trigger === item.trigger && item.interaction === 'click'
        );
    }

    /**
     * @typedef OverlayOpenDetail
     * @property {HTMLElement} content
     * @property {HTMLElement} [contentTip]
     * @property {boolean} delayed;
     * @property {number} offset;
     * @property {number} [skidding]
     * @property {Placement} [placement]
     * @property {'auto'} [receivesFocus]
     * @property {VirtualTrigger} [virtualTrigger]
     * @property {HTMLElement} trigger
     * @property {HTMLElement} [root]
     * @property {TriggerInteractions} interaction
     * @property {ThemeData} theme
     * @property {boolean} [notImmediatelyClosable]
     * @property {Promise<boolean>} [abortPromise]
     */

    /**
     *
     * @param {OverlayOpenDetail} details
     * @return {Promise<boolean>}
     */
    async openOverlay(details) {
        this.addEventListeners();
        if (this.findOverlayForContent(details.content)) {
            return false;
        }
        if (details.notImmediatelyClosable) {
            this._doesNotCloseOnFirstClick = true;
        }
        if (details.interaction === 'modal') {
            this.startTabTrapping();
        }

        /**
         * @type {ManagedOverlayContent}
         */
        const contentWithLifecycle =
            details.content;
        const {trigger} = details;
        if (contentWithLifecycle.overlayWillOpenCallback) {
            contentWithLifecycle.overlayWillOpenCallback({trigger});
        }

        if (details.delayed) {
            const cancelledPromise = this.overlayTimer.openTimer(
                details.content
            );
            const promises = [cancelledPromise];
            if (details.abortPromise) {
                promises.push(details.abortPromise);
            }
            const cancelled = await Promise.race(promises);
            if (cancelled) {
                if (contentWithLifecycle.overlayOpenCancelledCallback) {
                    contentWithLifecycle.overlayOpenCancelledCallback({
                        trigger,
                    });
                }
                return cancelled;
            }
        }

        if (details.root) {
            this.closeOverlaysForRoot(details.root);
        }
        if (details.interaction === 'click') {
            this.closeAllHoverOverlays();
        } else if (
            details.interaction === 'hover' &&
            this.isClickOverlayActiveForTrigger(details.trigger)
        ) {
            // Don't show a hover popover if the click popover is already active
            return true;
        }

        const activeOverlay = ActiveOverlay.create(details);

        if (this.overlays.length) {
            const topOverlay = this.overlays[this.overlays.length - 1];
            topOverlay.obscure(activeOverlay.interaction);
        }

        document.body.appendChild(activeOverlay);

        /**
         * The following work to make the new overlay the "top" of the stack
         * has to happen AFTER the current call stack completes in case there
         * is work there in to remove the previous "top" overlay.
         */
        await nextFrame();
        this.overlays.push(activeOverlay);
        await activeOverlay.updateComplete;
        this.addOverlayEventListeners(activeOverlay);
        if (typeof contentWithLifecycle.open !== 'undefined') {
            await nextFrame();
            // Without the rAF Firefox gets here to early
            // and is not able trigger the animation.
            contentWithLifecycle.open = true;
        }
        /**
         * @type {()=>Promise<void> | void}
         */
        let cb = () => {

        };
        if (contentWithLifecycle.overlayOpenCallback) {
            const {trigger} = activeOverlay;
            const {overlayOpenCallback} = contentWithLifecycle;
            cb = async () => await overlayOpenCallback({trigger});
        }
        await activeOverlay.openCallback(cb);
        return false;
    }

    /**
     * @public
     * @param {ActiveOverlay} activeOverlay
     */
    addOverlayEventListeners(activeOverlay) {
        activeOverlay.addEventListener('close', (() => {
            this.hideAndCloseOverlay(
                activeOverlay,
                true // animated?
            );
        }));
        switch (activeOverlay.interaction) {
            case 'replace':
                this.addReplaceOverlayEventListeners(activeOverlay);
                break;
            case 'inline':
                this.addInlineOverlayEventListeners(activeOverlay);
                break;
        }
    }

    /**
     * @public
     * @param {ActiveOverlay} activeOverlay
     */
    addReplaceOverlayEventListeners(activeOverlay) {
        activeOverlay.addEventListener('keydown', (event) => {
            const {code} = event;
            /* c8 ignore next */
            if (code !== 'Tab') return;

            event.stopPropagation();
            this.closeOverlay(activeOverlay.overlayContent);
            activeOverlay.tabbingAway = true;
            activeOverlay.trigger.focus();
            activeOverlay.trigger.dispatchEvent(
                new KeyboardEvent('keydown', event)
            );
        });
    }

    /**
     *
     * @param {ActiveOverlay} activeOverlay
     */
    addInlineOverlayEventListeners(activeOverlay) {
        activeOverlay.trigger.addEventListener(
            'keydown',
            activeOverlay.handleInlineTriggerKeydown
        );
        activeOverlay.addEventListener('keydown', (event) => {
            const {code, shiftKey} = event;
            /* c8 ignore next */
            if (code !== 'Tab') return;

            activeOverlay.tabbingAway = true;
            if (shiftKey) {
                const returnFocusElement = document.createElement('span');
                returnFocusElement.tabIndex = -1;
                if (activeOverlay.trigger.hasAttribute('slot')) {
                    returnFocusElement.slot = activeOverlay.trigger.slot;
                }
                activeOverlay.trigger.insertAdjacentElement(
                    'afterend',
                    returnFocusElement
                );
                returnFocusElement.focus();
                returnFocusElement.remove();
                return;
            }

            event.stopPropagation();
            const triggerWithLifecycle =
                activeOverlay.trigger;
            if (typeof triggerWithLifecycle.open !== 'undefined') {
                triggerWithLifecycle.open = false;
            }
            this.closeOverlay(activeOverlay.overlayContent);
            activeOverlay.trigger.focus();
        });


    }

    /**
     * @public
     * @param {HTMLElement} content
     */
    closeOverlay(content) {
        this.overlayTimer.close(content);
        requestAnimationFrame(() => {
            const overlayFromContent = this.findOverlayForContent(content);
            const overlaysToClose = [overlayFromContent];
            overlaysToClose.push(
                ...findOverlaysRootedInOverlay(overlayFromContent, this.overlays)
            );
            overlaysToClose.forEach((overlay) =>
                this.hideAndCloseOverlay(overlay)
            );
        });
    }

    /**
     * @private
     * @param {MouseEvent} event
     */
    handleMouseCapture(event) {
        const topOverlay = this.topOverlay;
        if (
            !event.target ||
            !topOverlay ||
            !topOverlay.overlayContent ||
            hasModifier(event) ||
            !isLeftClick(event)
        ) {
            this.preventMouseRootClose = true;
            return;
        }

        if (event.target instanceof Node) {
            const path = event.composedPath();
            if (path.indexOf(topOverlay.overlayContent) >= 0) {
                this.preventMouseRootClose = true;
                return;
            }
            this.preventMouseRootClose = false;
        }
    }

    /**
     * @private
     */
    closeAllHoverOverlays() {
        for (const overlay of this.overlays) {
            if (overlay.interaction === 'hover') {
                this.hideAndCloseOverlay(overlay, false);
            }
        }
    }

    /**
     * @private
     * @param {HTMLElement} root
     */
    closeOverlaysForRoot(root) {
        /**
         *
         * @type {ActiveOverlay[]}
         */
        const overlaysToClose = [];
        for (const overlay of this.overlays) {
            if (overlay.root && overlay.root === root) {
                overlaysToClose.push(overlay);
                overlaysToClose.push(
                    ...findOverlaysRootedInOverlay(overlay, this.overlays)
                );
            }
        }
        overlaysToClose.forEach((overlay) =>
            this.hideAndCloseOverlay(overlay, true, true)
        );
    }

    /**
     * @private
     * @method
     * @param {boolean} returnBeforeFocus
     * @param {HTMLElement} previousTrigger
     * @return {Promise<void>}
     */
    async manageFocusAfterCloseWhenOverlaysRemain(
        returnBeforeFocus = undefined,
        previousTrigger = undefined
    ) {
        const topOverlay = this.overlays[this.overlays.length - 1];
        topOverlay.feature();
        // Push focus in the the next remaining overlay as needed when a `type="modal"` overlay exists.
        if (topOverlay.interaction === 'modal' || topOverlay.hasModalRoot) {
            if (returnBeforeFocus) return;
            await (previousTrigger || topOverlay).focus();
        } else {
            this.stopTabTrapping();
        }
    }

    /**
     *
     * @param {ActiveOverlay} overlay
     */
    manageFocusAfterCloseWhenLastOverlay(overlay) {
        this.stopTabTrapping();
        const isModal = overlay.interaction === 'modal';
        const isReceivesFocus = overlay.receivesFocus === 'auto';
        const isReplace = overlay.interaction === 'replace';
        const isInline = overlay.interaction === 'inline';
        const isTabbingAwayFromInlineOrReplace =
            (isReplace || isInline) && !overlay.tabbingAway;
        overlay.tabbingAway = false;
        if (!isModal && !isReceivesFocus && !isTabbingAwayFromInlineOrReplace) {
            return;
        }
        // Manage post closure focus when needed.
        /**
         *
         * @type {ShadowRoot}
         */
        const overlayRoot = overlay.overlayContent.getRootNode();
        const overlayContentActiveElement = overlayRoot.activeElement;
        /**
         * @type {ShadowRoot}
         */
        let triggerRoot;
        /**
         * @type {Element | null}
         */
        let triggerActiveElement;
        /**
         * @return {boolean}
         */
        const contentContainsActiveElement = () =>
            overlay.overlayContent.contains(overlayContentActiveElement);
        /**
         *
         * @return {boolean}
         */
        const triggerRootContainsActiveElement = () => {
            /**
             *
             * @type {ShadowRoot}
             */
            triggerRoot = overlay.trigger.getRootNode();
            triggerActiveElement = triggerRoot.activeElement;
            return triggerRoot.contains(triggerActiveElement);
        };
        /**
         *
         * @return {boolean}
         */
        const triggerHostIsActiveElement = () =>
            triggerRoot.host && triggerRoot.host === triggerActiveElement;
        // Return focus to the trigger as long as the user hasn't actively focused
        // something outside of the current overlay interface; trigger, root, host.
        if (
            isModal ||
            contentContainsActiveElement() ||
            triggerRootContainsActiveElement() ||
            triggerHostIsActiveElement()
        ) {
            overlay.trigger.focus();
        }
    }

    /**
     * @typedef ManagedOverlayContent
     * @property {boolean} open - Allow the overlaid content to do something before it is thrown.
     * @property {(args: { trigger: HTMLElement }) => void} [overlayWillOpenCallback]
     * @property {(args: { trigger: HTMLElement }) => void} [overlayOpenCallback]
     * @property {(args: { trigger: HTMLElement }) => void} [overlayOpenCancelledCallback]
     * @property {(args: { trigger: HTMLElement }) => boolean} [overlayWillCloseCallback]
     * @property {(args: { trigger: HTMLElement }) => void} [overlayCloseCallback]
     * @property {Promise<boolean>} [updateComplete]
     */

    /**
     * @typedef OverlayOpenCloseDetail
     * @property {TriggerInteractions} interaction
     * @property {'external-click'} [reason]
     */

    /**
     * @method
     * @private
     * @async
     * @param {ActiveOverlay} overlay
     * @param {boolean} animated
     * @param {boolean} returnBeforeFocus
     * @return {Promise<void>}
     */
    async hideAndCloseOverlay(
        overlay = undefined,
        animated = undefined,
        returnBeforeFocus = undefined,
    ) {
        if (!overlay) {
            return;
        }
        /**
         *
         * @type {ManagedOverlayContent}
         */
        const contentWithLifecycle =
            overlay.overlayContent;
        if (
            typeof contentWithLifecycle.overlayWillCloseCallback !== 'undefined'
        ) {
            const {trigger} = overlay;
            if (contentWithLifecycle.overlayWillCloseCallback({trigger})) {
                return;
            }
        }
        await overlay.hide(animated);
        if (typeof contentWithLifecycle.open !== 'undefined') {
            contentWithLifecycle.open = false;
        }
        if (contentWithLifecycle.overlayCloseCallback) {
            const {trigger} = overlay;
            await contentWithLifecycle.overlayCloseCallback({trigger});
        }

        if (overlay.state !== 'dispose') return;

        const index = this.overlays.indexOf(overlay);
        if (index >= 0) {
            this.overlays.splice(index, 1);
        }

        if (this.overlays.length) {
            await this.manageFocusAfterCloseWhenOverlaysRemain(
                returnBeforeFocus || overlay.interaction === 'hover',
                overlay.trigger
            );
        } else {
            this.manageFocusAfterCloseWhenLastOverlay(overlay);
        }

        await overlay.updateComplete;
        overlay.remove();
        overlay.dispose();

        overlay.trigger.dispatchEvent(
            /**
             * @type{CustomEvent<OverlayOpenCloseDetail>}
             */
            new CustomEvent('sp-closed', {
                bubbles: true,
                composed: true,
                cancelable: true,
                detail: {
                    interaction: overlay.interaction,
                },
            })
        );
    }

    /**
     * @private
     * @method
     * @return {Promise<void>}
     */
    closeTopOverlay() {
        return this.hideAndCloseOverlay(this.topOverlay, true);
    }

    /**
     * A "longpress" occurs before the "click" that creates it has occured.
     * In that way the first click will still be part of the "longpress" and
     * not part of closing the overlay.
     * @private
     * @type {boolean}
     */
    _doesNotCloseOnFirstClick = false;

    /**
     * @private
     * @method
     * @param {Event} event
     */
    handleMouse(event) {
        if (this._doesNotCloseOnFirstClick) {
            this._doesNotCloseOnFirstClick = false;
            return;
        }
        if (this.preventMouseRootClose || event.defaultPrevented) {
            return;
        }
        const overlaysToClose = [];
        /**
         * Find the first overlay that should be closed by this and include it in the
         * array of overlays for closure.
         *
         * Event path dictates closure while the click event:
         * - did not occur within the overlay content of the overlay
         * AND was
         * - not triggered by something in the click event's composed path
         * OR
         * - not a "hover" overlay
         * Select the overlay for closure
         */
        let index = this.overlays.length;
        while (index && overlaysToClose.length === 0) {
            index -= 1;
            const overlay = this.overlays[index];
            const path = event.composedPath();
            const eventPathDictatesClosure =
                (!path.includes(overlay.trigger) ||
                    overlay.interaction !== 'hover') &&
                !path.includes(overlay.overlayContent);
            if (eventPathDictatesClosure) {
                overlaysToClose.push(overlay);
            }
        }
        /**
         *
         * @type {HTMLElement | undefined}
         */
        let root = this.topOverlay?.root;
        let overlay = parentOverlayOf(root);
        while (root && overlay) {
            overlaysToClose.push(overlay);
            overlay = parentOverlayOf(root);
            root = overlay?.root;
        }
        if (overlay) {
            overlaysToClose.push(overlay);
        }
        overlaysToClose.forEach((overlay) => this.hideAndCloseOverlay(overlay));
    };

    /**
     * @private
     * @method handleKeydown
     * @param {KeyboardEvent} event
     */
    handleKeydown(event) {
        if (event.code === 'Escape') {
            this.closeTopOverlay();
        }
    }

    /**
     * @private
     * @method handleResize
     */
    handleResize() {
        if (this.handlingResize) return;

        this.handlingResize = true;
        requestAnimationFrame(async () => {
            const promises = this.overlays.map((overlay) =>
                overlay.updateOverlayPosition()
            );
            await Promise.all(promises);
            this.handlingResize = false;
        });
    }

}

